'''
    Document indexer and seacher Win32 COM server

SYNOPSIS
    Implements three COM Server classes:

    docindexer.indexer  -- indexes directory tree of document files.
    docindexer.searcher -- query document index for matching files.
    docindexer.utils    -- file parser utilities.

HOME PAGE
    http://www.methods.co.nz/docindexer/

COPYING
    Copyright (C) 2007 Stuart Rackham. Free use of this software is
    granted under the terms of the MIT License.
'''
__author__ = 'Stuart Rackham <srackham@methods.co.nz>'
# The following line is set automatically by Rakefile 'vers' task.
__version__ = '0.9.4.3'

import string, os, re, markdown
from win32com.server.exception import COMException
import winerror
from docindexer.common import APP_DIR, EDocIndexer, Log
from docindexer.parsers import Parsers
from docindexer.docsearcher import DocSearcher
from docindexer.docindexer import DocIndexer

class Indexer:
    '''Creates and updates documents index.
    '''
    # Registration stuff to support fully automatic register/unregister
    _reg_progid_ = 'docindexer.indexer'
    _reg_desc_ = 'DocIndexer document indexer'
    _reg_clsid_ = '{2E41F36C-F163-477D-8E30-FC2DC7D93576}'
    # Published methods and attributes.
    _public_methods_ = ['BuildIndex','OpenIndex','NextFile','AddFile','CloseIndex']
    _public_attrs_ = ['IndexedCount','SkippedCount','BytesIndexed','LogFile']
    _readonly_attrs_ = ['IndexedCount','SkippedCount','BytesIndexed','LogFile']

    def __init__(self):
        self.indexer = None
        self.files_iter = iter([])
        # Default log file location.
        self.logfile = None
        self.reset_counts()

    def reset_counts(self):
        self.indexed_count = 0
        self.skipped_count = 0
        self.bytes_indexed = 0L

    def update_counts(self):
        self.indexed_count = self.indexer.indexed_count
        self.skipped_count = self.indexer.skipped_count
        self.bytes_indexed = self.indexer.bytes_indexed

    def assert_indexer_open(self):
        if self.indexer is None:
            raise COMException(desc='document index has not been opened',
                            scode=winerror.E_FAIL)

    def initialize(self, docsdir, incremental=True, includes='*', excludes='',
                   dryrun=False, logfile=None, optimize=True):
        try:
            self.indexer = DocIndexer(docsdir)
        except Exception,e:
            raise COMException(desc='%s' % e, scode=winerror.E_FAIL)
        self.update_counts()
        self.indexer.includes = [s.strip() for s in includes.split('|')]
        if not excludes:
            self.indexer.excludes = []
        else:
            self.indexer.excludes = [s.strip() for s in excludes.split('|')]
        self.indexer.dryrun = dryrun
        self.indexer.rebuild = not incremental
        self.indexer.optimize = optimize
        if logfile:
            self.logfile = logfile
            try:
                # Create/truncate log file.
                Log.stream = open(self.logfile,'w+')
            except Exception,e:
                raise COMException(desc='unable to open log file: %s' % e,
                                   scode=winerror.E_FAIL)
        self.indexer.log_header()
        try:
            self.indexer.parsers.open()
        except Exception,e:
            raise COMException(desc='unable to open parsers: %s' % e,
                               scode=winerror.E_FAIL)

    def cleanup(self):
        self.indexer.log_footer()
        try:
            Log.close()
        except Exception,e:
            raise COMException(desc='unable to close log file: %s' % e,
                               scode=winerror.E_FAIL)
        try:
            self.indexer.parsers.close()
        except Exception,e:
            raise COMException(desc='unable to close parsers: %s' % e,
                               scode=winerror.E_FAIL)
        self.indexer = None

    def BuildIndex(self, docsdir, incremental=False, includes='*', excludes='',
                   dryrun=False, logfile=None, optimize=True):
        ''' Index the docsdir in one operation. Because indexing can take a
        long time using this method can result in an unresponsive client
        application -- use the OpenIndex/NextFile/AddFile/CloseIndex interface
        to implement a responsive client application.'''
        self.initialize(docsdir, incremental, includes, excludes,
                        dryrun, logfile, optimize)
        try:
            self.indexer.update_index()
            self.update_counts()
        except EDocIndexer,e:
            raise COMException(desc='%s' % e,
                               scode=winerror.E_FAIL)
        except Exception,e:
            raise COMException(desc='unexpected error: %s' % e,
                               scode=winerror.E_FAIL)
        finally:
            self.cleanup()

    def OpenIndex(self, docsdir, incremental=False, includes='*', excludes='',
                  dryrun=False, logfile=None, optimize=True):
        ''' Open index for directory docsdir.  This method is used with
        MextFile(), AddFile() and CloseIndex() to do an index rebuild
        file-by-file.
        docsdir     -- Directory to be recursively indexed.
        incremental -- If True only new and updated files are indexed,
                       missing files are removed from the index.
        includes    -- String containing '|' separated list of file
                       name wildcards specifying files to be indexed.
        excludes    -- String containing '|' separated list of file
                       name wildcards specifying files to be excluded.
        dryrun      -- If True the indexer does not update the index.
        logfile     -- Log file path.
        optimize    -- If True the index is optimised.
        '''
        self.initialize(docsdir, incremental, includes, excludes,
                        dryrun, logfile, optimize)
        try:
            self.files_iter = self.indexer.files_to_index_gen()
            self.indexer.open_writer()
        except EDocIndexer,e:
            raise COMException(desc='%s' % e,
                               scode=winerror.E_FAIL)
        except Exception,e:
            raise COMException(desc='unexpected error: %s' % e,
                               scode=winerror.E_FAIL)

    def NextFile(self):
        '''Return full path name of next file to be indexed.
        Return empty string when there are no indexable files remaining.
        '''
        self.assert_indexer_open()
        try:
            f = self.files_iter.next()
        except StopIteration:
            f = ''
        except EDocIndexer,e:
            raise COMException(desc='%s' % e,
                               scode=winerror.E_FAIL)
        except Exception,e:
            raise COMException(desc='unexpected error: %s' % e,
                               scode=winerror.E_FAIL)
        return f

    def AddFile(self, filename):
        ''' Add a file to the open index. The filename is a string containing
        the full pathname of the document file.  Returns True if the file is
        successfully indexed. An error is raised if an unexpected error occurs.
        '''
        self.assert_indexer_open()
        try:
            self.indexer.add_doc(filename)
        except EDocIndexer,e:
            Log.close()
            raise COMException(desc='%s' % e,
                               scode=winerror.E_FAIL)
        except Exception,e:
            Log.close()
            raise COMException(desc='unexpected error: %s' % e,
                               scode=winerror.E_FAIL)
        n = self.indexed_count
        self.update_counts()
        return self.indexed_count == n + 1

    def CloseIndex(self):
        '''Close the index that was opened with the OpenIndex method.
        '''
        self.assert_indexer_open()
        self.indexer.close_writer()
        self.cleanup()

    def IndexedCount(self):
        '''Return the number of files indexed. Readonly property.'''
        return self.indexed_count

    def SkippedCount(self):
        '''Return the number of files skipped. Readonly property.'''
        return self.skipped_count

    def BytesIndexed(self):
        '''Return the number of bytes indexed. Readonly property.'''
        return self.bytes_indexed

    #DEPRECATED
    def LogFile(self):
        '''Return the file name of the log file or a blank string if none was
        specified.'''
        if self.logfile:
            return self.logfile
        else:
            return ''


class Searcher:
    '''Index searcher for running search queries.
    '''
    # Registration stuff to support fully automatic register/unregister
    _reg_progid_ = 'docindexer.searcher'
    _reg_desc_ = 'DocIndexer document searcher'
    _reg_clsid_ = '{E4ADCAF1-5BE0-469B-B496-BFC5C10A29DB}'
    # Published methods and attributes.
    _public_methods_ = ['AndSearch','OrSearch','PhraseSearch','QuerySearch',
                        'BoolSearch',   # DEPRECATED
                        'OpenIndex','NextFile','CloseIndex']
    _public_attrs_ = ['ParsedQuery','TotalHits']
    _readonly_attrs_ = ['ParsedQuery','TotalHits']

    def __init__(self):
        self.searcher = None
        self.hits_iter = iter([])

    def assert_searcher_open(self):
        if self.searcher is None:
            raise COMException(desc='search index has not been opened',
                               scode=winerror.E_FAIL)

    def OpenIndex(self, searchdir):
        '''Open search index to search for documents in and below
        directory searchdir.
        '''
        try:
            self.searcher = DocSearcher(searchdir)
        except EDocIndexer,e:
            raise COMException(desc='%s' % e,
                               scode=winerror.E_FAIL)
        except Exception,e:
            raise COMException(desc='unexpected error: %s' % e,
                               scode=winerror.E_FAIL)

    def CloseIndex(self):
        '''Close searcher.
        '''
        self.assert_searcher_open()
        self.searcher = None

    def NextFile(self):
        '''Return as string containing the full path name of the file
        satisfying the current search.
        Return empty string when there are no files remaining.
        '''
        self.assert_searcher_open()
        try:
            hit = self.hits_iter.next()
            f = self.searcher.fspath(hit['pathname'])
        except StopIteration:
            f = ''
        except Exception,e:
            raise COMException(desc='unexpected error: %s' % e,
                               scode=winerror.E_FAIL)
        return f

    def search(self, query):
        '''Execute query. Found files initialize the hits iterator.'''
        self.assert_searcher_open()
        try:
            self.searcher.parse_query(query)
        except EDocIndexer,e:
            raise COMException(desc='%s' % e,
                               scode=winerror.E_FAIL)
        except Exception,e:
            raise COMException(desc='unexpected error: %s' % e,
                               scode=winerror.E_FAIL)
        self.hits_iter = self.searcher.hits_gen()

    def AndSearch(self, words):
        '''Search for files containing all words in the words string.'''
        # Prepend '+' to all words.
        words = ['+'+w for w in words.split()]
        words = string.join(words)
        self.search(words)

    def OrSearch(self, words):
        '''Search for files containing one or more words in the words string.
        '''
        self.search(words)

    def PhraseSearch(self, phrase):
        '''Search for files containing the phrase string.'''
        phrase = phrase.strip()
        phrase = '"' + phrase + '"'
        self.search(phrase)

    def QuerySearch(self, query):
        '''Search for files satisfying the query.'''
        self.search(query)

    # DEPRECATED
    def BoolSearch(self, query):
        self.QuerySearch(query)

    def ParsedQuery(self):
        '''Return the most recent parsed document search query.'''
        self.assert_searcher_open()
        if self.searcher.query:
            return self.searcher.query.toString()
        else:
            return ''

    def TotalHits(self):
        '''Return the total number of documents found.'''
        self.assert_searcher_open()
        return self.searcher.hits.length()

class Utils:
    '''Utility routines for use by non-indexer applications.
    '''
    # Registration stuff to support fully automatic register/unregister
    _reg_progid_ = 'docindexer.utils'
    _reg_desc_ = 'DocIndexer utility routines'
    _reg_clsid_ = '{22F048CB-09CB-477A-94E2-60614F52F9E0}'
    # Published methods and attributes.
    _public_methods_ = ['TextContent','Markdown']

    def __init__(self):
        self.parsers = Parsers()

    def TextContent(self, filename):
        '''Return text from file.
        '''
        try:
            result = DocIndexer.file_to_text(filename)
        except EDocIndexer,e:
            raise COMException(desc='%s' % e,
                               scode=winerror.E_FAIL)
        except Exception,e:
            raise COMException(desc='unexpected error: %s' % e,
                               scode=winerror.E_FAIL)
        result = dos_eol(result)
        # TODO: How relevant/sensible is output filtering in a unicode
        #       environment?
        result = re.sub(r'(?m)[\x00-\x08\x0B-\x0C\x0E-\x1F]', '', result)
        return result

    def Markdown(self, text):
        '''Apply Markdown to string.
        '''
        try:
            result = markdown.markdown(text)
        except Exception,e:
            raise COMException(desc='unexpected error: %s' % e,
                               scode=winerror.E_FAIL)
        # Because markdown translates \n, \r, \n\r, \r\n to a single \n
        # but mail (RFC822bis) stipulates that all LF's are preceeded by a CR.
        result = dos_eol(result)
        return result


def Register():
    import win32com.server.register
    win32com.server.register.UseCommandLine(Utils)
    win32com.server.register.UseCommandLine(Indexer)
    win32com.server.register.UseCommandLine(Searcher)

def dos_eol(s):
    # Convert UNIX lf line termination to DOS crlf.
    # Replace \n not preceeded by \r.
    result = s
    result = re.sub(r'(?m)(?<!\r)\n', '\r\n', result)
    # Replace \r not followed by \n.
    result = re.sub(r'(?m)\r(?!\n)', '\r\n', result)
    # Replace form feeds with blank line.
    result = re.sub(r'(?m)\f', '\r\n', result)
    return result

if __name__=='__main__':
    # Command-line options:
    # [--register] [--unregister] [--quiet] [--debug]
    Register()

# vim: ts=4 sw=4:
